# Copyright (c) 2008, RPI-ACM, Jesse Kempf <jessekempf@gmail.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# - Redistributions of source code must retain the above copyright notice,
#     this list of conditions and the following disclaimer.
# - Redistributions in binary form must reproduce the above copyright
#     notice, this list of conditions and the following disclaimer in the
#     documentation and/or other materials provided with the distribution.
# - Neither the names of RPI-ACM nor the names of its contributors
#     may be used to endorse or promote products derived from this software
#     without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
#  TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
#  PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
#  OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
#  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
#  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
#  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
#  LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
#  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
#  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""Unixutils Reliable Blocking RPC

This module provides support for the style of blocking RPC needed to
do privilege separation. The CommandChannel class implements the RPC
mechanism and the exported GenericCommands package contains a set of
Commands loaded by default into all CommandChannel instances."""

# pylint: disable-msg=W0142
import inspect

import unixutils.privsep.msgsock as msgsock
from unixutils.privsep.command import CommandContext, CMDException, Message, \
	Command
import unixutils.testing.asserts as asserts
from unixutils.privsep.commands.generic import GenericCommands
import sys

__all__ = ['CommandChannel', 'ChannelClosedError']

class CommandNotFound(RuntimeError):
	"""Indicates a given command is not registered in a CommandChannel's
	command table."""
	pass

class CommandChannel(object):
	"""A synchronous command-oriented channel.

	The CommandChannel is the basis of privsepped communication between
	a monitor and slave. It is a simple blocking RPC mechanism layered
	atop a MessageSocket.

	A channel supports the loading of groups of commands in the form of
	CommandPackages.

	Once a CommandChannel has the appropriate Commands loaded, the
	appropriate function (set_parent/set_child) must be called to configure
	the channel for a role.

	When properly configured, a CommandChannel is as simple to use as
	calling the 'call' and 'service_call' methods.
	"""

	def __init__(self):
		"""Create a CommandChannel suitable for one-to-one communication
		between a monitor and slave.

		Note well: the channel is not ready for use until either
		set_parent() or set_child(), whichever is appropriate, has been
		called.
		"""

		self.mchan = msgsock.MessageChannel()
		self.ctable = {}

	def set_child(self):
		"""Configure the channel for use in the slave."""

		self.mchan.set_child()

	def set_parent(self):
		"""Configure the channel for use in the monitor."""
		self.mchan.set_parent()

	def fileno(self):
		"""Retrieve the file descriptor underlying the channel."""

		return self.mchan.fileno()

	def add_command(self, command):
		"""Register the provided command for use with the channel."""

		if command.name not in self.ctable:
			self.ctable[command.name] = command

	def load_commands(self, cmdpkg):
		"""Load commands from the provided CommandPackage."""
		for cmd in cmdpkg:
			self.add_command(cmd)

	def call(self, cmd, *args, **kwargs):
		"""Call the given command with the provided positional and keyword
		arguments. The arguments are command specific and should be
		documented with it.
		"""

		if isinstance(cmd, Command):
			try:
				cmd = self.ctable[cmd.name]
			except KeyError:
				raise CommandNotFound("Command '%s' not registered." % cmd.name)

			return cmd.invoke(
				*((CommandContext(self, cmd),) + args),
				**kwargs)
		else:
			self.mchan.send(cmd.name, args, kwargs)

	def push(self, cmd, *args, **kwargs):
		"""Layer: CPI

		Send the opcode for the given command and the positional and
		keyword arguments to the other side of the channel without calling
		the invoke() method of the command. Used for commands that manage
		the channel themselves.
		"""

		self.mchan.send(cmd.name, args, kwargs)

	def service_call(self, *assert_call):
		"""Service a single command call from the other side of the channel.

		A list of commands may be provided, in which case an AssertionError
		will be raised if the other side requests a command not in the
		list.
		"""
		(cmdname, args, kwargs) = self.mchan.recv()

		cmd = self.ctable[cmdname]

		if inspect.isclass(cmd) and issubclass(cmd, CMDException):
			raise cmd(*args, **kwargs)

		if isinstance(cmd, Message) and not isinstance(cmd, Command):
			return (cmd, (args, kwargs))

		if assert_call:
			assert cmd in assert_call, \
			"Wanted: %s, got: '%s'" % ([str(x) for x in assert_call], cmd)

		ctx = CommandContext(self, cmd)

		try:
			rval = self.ctable[cmd.name].handle(*([ctx] + args), **kwargs)
			return (cmd, rval)
		except Exception, exc:
			# Provide some extra indications where this came from
			# In particular, tell which Command raised and use the
			# traceback from that raise.
			message = "'%s' (error in %s)" % \
				(exc, cmdname)
			trace = sys.exc_info()[2] # trace from exception just thrown
			raise type(exc), message, trace

	def close(self):
		"""Close this command channel and free all associated resources."""
		self.mchan.close()

	def test(self):
		"""Run each registered command's test suite."""
		import os

		for command in self.ctable:
			print command
		print '=' * 10

		order = [self.ctable[command] for command in self.ctable
			if self.ctable[command].testable()]
		order.extend(order)
		order.extend([x for x in reversed(order)])

		print "Testing order: %s" % [str(x) for x in order]
		print '=' * 10

		self.load_commands(GenericCommands)

		if os.fork():
			asserts.run_tests(lambda: self._test_parent(order))
		else:
			asserts.run_tests(lambda: self._test_child(order))

	def _test_parent(self, order=None):
		"""Run the monitor side of the test suite."""
		order = order if order is not None else []
		self.set_parent()

		for command in order:
			ctx = CommandContext(self, command)
			command.test_parent(ctx)

		self.service_call(GenericCommands.EXIT)

	def _test_child(self, order=None):
		"""Run the slave side of the test suite."""
		order = order if order is not None else []
		self.set_child()

		for command in order:
			print '-' * 13
			print "Testing: %s" % command
			ctx = CommandContext(self, command)
			command.test_child(ctx)
			print "Testing of %s complete." % command

		self.call(GenericCommands.EXIT, val=0)
